作为业务开发的工具,优秀容器框架可以很大程度上避免重复遭轮子,可以提供一些基本轻量的容器原语,方便上层实现。对于各种现代编程语言来说都是不可忽视的 / 必须要实现的,即使在某些 “大道至简” 的语言中,也有最基本的实现。在标准库中没有的往往也会被社区生态库所补充。显然,容器框架在各种实现中面临的问题必然是类似的,实现思路上也或多或少会有相似的地方。因此有必要进行一些归纳。
集合 / 容器作为常用的工具类型,我认为可以这样总结:通过几种底层实现方式,实现一个具有某些特性(如有序、线程安全)的逻辑结构。
实现方式:Linked、Tree、Array、Hash...
逻辑结构:List、Queue、Set、Map...
具有的特性:Sorted、Navigable、ConcurrentSafe、Immutable、RandomAccess...
实现方式决定了逻辑结构具有哪些特性,为了实现某些特性,往往需要采用合适的数据结构。
比如 Sorted/Navigable 一般通过链式 / 树结构保证。并发安全特性通常需要引入锁来实现,也有通过不可变的方式保证并发安全。RandomAccess 需要 Array 实现。
本篇的目的则是跳出特定语言范畴以屏蔽一些不必要的底层细节,如具体的方法实现,简单概述一下各种语言中常见容器的实现思路,以及一些为了可用性可能需要的机制。
Array/Hash#
数组是一种具有 RandomAccess 特性的存储结构。从内存视角来看,是一块连在一起的内存。每个元素占用固定大小的内存。通过基础地址 + 偏移机制就可以做到随机访问。Hash 表底层也采用数组的形式实现快速访问。
这种结构的快速访问性能很好,但是问题在于,数组需要固定的内存大小,容量固定,这是难以接受的。
当然这也并不局限于数组,我们也可以规定一个固定大小的类型,从而加快访问其中的数据。
但是内存固定的特性在提高性能的同时,也会减少动态能力。
但多数情况下,我们既要动态特性,也要性能。
因此对于这类底层以 Array 实现的容器类型,为了避免存储的内容达到容器上限,往往会增加动态扩容机制。
ArrayList / 动态数组#
常用的 List 底层一般都是 Array。尽管有用链表实现的 List,但从实际使用来看,Array 实现使用更多。在 Java 中是 ArrayList,在 python 就是 list,在 golang 中是 slice。
当存储的元素增加达到底层数组容量时,就会触发扩容机制。
扩容机制
底层 Array 的扩容机制相对来说思路很简单,开辟一段新的、更大的数组内存,将原来的数据复制到新数组上即可。因此核心问题在于扩容大小的选择。
在 Java 的 ArrayList 中,默认大小为 10(初始为 0,首次使用分配 10),默认是按增长 1.5 倍的方式进行扩容,若新容量仍不够则按当前需要的容量进行扩容。 Max(minCapacity, 1.5*oldCapacity)
。这种方式较为粗暴但有效。尽管这种扩容机制使得底层 Array 可以视为无限长,但结合实际来看,为其设置一个容量上限是合理的 (Integer.MAX_VALUE
)
在 Golang 的 Slice 当中,扩容机制的容量计算在 1.17 及以前是这样的:
- 如果需求容量大于当前容量的两倍就会使用需求容量;
- 如果当前切片的长度小于 1024 就会将容量翻倍;
- 如果当前切片的长度大于 1024 就会每次增加 1/4 的容量,直到新容量大于期望容量;
func calculateNewCap(requireCaq, oldCap int) int {
if requireCaq > 2 * oldCap {
return requireCap
}
if oldCap < 1024 {
return 2 * oldCap
}
ans := oldCap
for requireCap > ans {
ans += ans/4
}
return ans
}
自 1.18 起,Golang 的扩容机制则进一步优化,将 2 倍扩容的限度从 1024 降至 256,增长函数也进行了调整。
func calculateNewCap(requireCaq, oldCap int) int {
if requireCaq > 2 * oldCap {
return requireCap
}
if oldCap < 256 {
return 2 * oldCap
}
ans := oldCap
for requireCap > ans {
ans += (ans + 256 * 3)/4
}
return ans
}
值得注意的是,Golang 的 Slice 扩容并不意味着底层 Array 的扩容,比如下图 B 进行扩容,可能只是涵盖[20,30,40,50]
这个部分。这里讨论的是底层 Array 的扩容,但是机制是类似的。
这种动态数组除了具有扩容机制之外,还可以引入切片机制 (sublist/slice)
多个切片可以指向同一个底层数组的不同部分,因此对切片的更改会反映到共享同一个底层数组的其他切片上。
HashMap / 字典#
Map 作为 KV 键值对的映射容器,可以用来通过 key 快速的查找。实现上往往采用 Hash 表的方式。
Hash 表是一个具有随机访问特性的连续数组。
在构建时,显然难以预料表的容量需要多大。同样需要动态扩容。
理想的 Hash 表的思路是通过 index = hash(key)
的方式快速拿到数组索引。但是现实是 hash 冲突不可避免。而 “开放地址法” 也只是在理论上有价值。拉链法就是各个语言实现的首选。
一般思路下,伪代码如下:
hash = hashFunc(key)
,计算 hash 值bucketIndex = getBucketIdx(hash)
,通过hash
计算出bucketIdx
bucket = getBucket(bucketIdx)
,bucketIdx
获取可能所属的bucket
kvPair = findInBucket(key, hash)
,通过 hash 值在bucket
内部进一步查找所需的元素
扩容机制
在 Java 中,一方面,当装载因子大于一个特定值(如 0.75
)时,会按照 2 次幂触发扩容,将 bucket 容量翻倍。另外一方面,当 bucket
中的链表长度大于 8 时,触发链表变换,变为红黑树。
在 Golang 中,同样采用类似的方式进行存储。每个 bucket 中有以多个 bmap 连接而成的链表,同时每个 bmap 中最多保存 8 个 kv。操作时通过 hash 找到 bucket 之后在链表中逐个比对 topHash 和 key 值。
当一个 bucket 中的 bmap 超过一定数量之后就进行扩容。
扩容策略:
- 等量调整,当某一个 bucket 中的 bmap 链太长,进行调整,缩短 bmap 链
- 2 倍扩容,当装载因子太大 (大于 6.5),进行两倍扩容,每个 bmap 都分流到两个新的 bmap 。
扩容 / 调整的过程并非是原子的,扩容开始时会分配好新的 bucket,但是具体的数据迁移会分散到之后的每次 map 操作中。
LinkedHashMap
在有些实现中,通过为 HashMap 中的元素增加两个指针、将所有元素通过双向链表连接起来,形成具有插入有序的 HashMap。这种实现具有 Navigable
的特性。
Set#
Set 作为一种不重复的集合,只需要保证值不重复好。正好,map 的 key 就是不重复的。因此在大量实现中底层都采用 hashMap,在 golang 中甚至没有可用的 set,直接推荐用 map 替代。
Linked/Tree#
链表、树形结构都是通过指针进行定位的,不具备 RandomAccess 的特性。相对于 Array 在内存中的紧密排布,其排布十分松散。这种实现并不可以快速访问,但是在需要修改或是频繁按条件查找的场景下会更有效。
LinkedList / 链表#
... 跳过好了,这属于基本操作了,尽管理论上,链表的插入性能优秀,但是查找性能却不如 ArrayList。而且相比于 Array 实现的,因为局部性的原因(CPU Cache),实践中 ArrayList 往往也有更好的效果,在中间插入的表现也要优于 / 持平 LinkedList。Java 中的 LinkedList 作者也明确指出并不实用。
TreeMap#
树状 Map 的查询性能并不如 HashMap 那么快、但是可以很方便的实现有序性。Java 中采用红黑树实现,而 Rust 中使用 B 树实现(对 cache 更友好,参见:BTreeMap in std::collections - Rust (rust-lang.org))。
一直很不解的是 TreeMap 的实际使用场景是什么。除了有序性,似乎在性能方面会牺牲更多。(比如金融系统中按时间为 key 的股票价格?)
TreeSet#
类似的,TreeSet 底层往往使用 TreeMap 实现,对 TreeMap 进行了一层封装。
Queue/Stack#
从定义上来讲,Queue/Stack 及其变体一般只涉及在队列头尾进行操作,因此非常适合使用链式结构实现。链式结构不需要考虑扩容,应用上的特性也只需要考虑并发安全。
PriorityQueue#
优先队列底层一般实现也是通过大小堆实现的,本质上也是一颗树。
关于 Iterator#
Iterator 是迭代器,是提供给外部一个用来遍历的迭代器接口,可以屏蔽各种不同类型的细节。
在 Java 的 Iterator 实现中,基本都会有 Fail-fast 机制,其目的只是为了让 BUG 尽可能早的显现出来(显然,在迭代时修改原有容器是不合理的)。
并发特性#
相比于有序,随机访问、可前后导航(Navigable)这些由底层结构就能够决定的特性,并发相对特殊一点。尽管可以通过强行遵循不可变原则强行实现集合本身的并发安全,但作为基础库而言,必然还是需要考虑可变性质的。那么就必须要引入锁的实现。
在简单的数据结构中(如 ArrayList),可以简单的加上一把大锁保证并发安全性。在复杂的数据结构中,则可以进一步细粒度锁定,以减少锁带来的性能损失。如 golang 中的 sync.map
,Java 中的ConcurrentHashMap
。
ConcurrentHashMap#
Java 的 ConcurrentHashMap 在 1.7 -> 1.8 过程中发生了较大的变更。
其中 1.7 版本采用分段的方式。这一层 segment 数量固定(初始化后不可变,默认 16 个),每个段中有第二层 bucket + 链表的结构。每次操作时,通过 hash 确定所在的段,每个段都有一把锁,如果并发操作不出现在同一个段内,就不会发生冲突。
在 1.8 中则摒弃了分段的方式。采用与 1.8 中 HashMap 的 bucket + 链表 / 红黑树的思路。但仍然是对每个 bucket 进行加锁。需要对初始化 / 扩容机制进行处理。初始化采用 CAS 的方式初始化出 buckets。在扩容时,一方面需要新建 buckets、另外一方面要转移 bucket 中的内容,原来的内容一分为二、分流至新 bucket 中。
在对 hashmap 修改时可能会进行扩容。一个线程在准备扩容时确定好大小,然后将扩容任务拆分为多个子任务,多个并发线程自行获取转移任务进行执行,转移时给 bucket 上锁。
sync.map#
golang 标准库中的 sync.map 底层分为 read/dirty 两个 map,通过空间换时间的思路提高并发读的性能。map 中不直接存储 kv,value 采用包装后的 entry (v) 进行操作,从而可以使得对 entry 中值的操作对两个 map 可见。
其基本思路是将大部分读取放在 read 中利用 lock-free 加快读取 / 删除 / 更新,利用锁在 dirty 中进行写入 / 更新。具体应当结合源码查看Go 1.9 sync.Map 揭秘 (colobu.com) go/src/sync/map.go · golang/go (github.com)。
在查找时:
- 先从 read 中读取,如果存在直接返回
- 如果不存在就给 dirty 锁,到 dirty 中查询,如果 miss 次数太多(多余 dirty),就顺便将 read 替换为 dirty,dirty 替换为 nil。(read/dirty 均为 atomic.Pointer,即使并发,这种对 read 的替换也是安全的,且此时持有锁,dirty 也是安全的)
在插入时: - 如果是个 read 中已经存在 (未标记为删除) 的 key,直接更新(atomic)。
- dirty 加锁
- 如果是个 read 中已经标记为删除的 key,在 dirty 中插入 / 更新(此处 dirty 有 key,一定被标记为删除)。
- 如果是个 read 中不存在的 key,在 dirty 中更新
- 如果 dirty 中也不存在,将 read 中没有标记为删除的键值对复制到 dirty 中,插入 dirty。
在删除时: - 如果 read 存在,直接在 read 中标记为删除 (dirty 也会受到影响,同一个 entry),直接返回
- 如果 read 不存在,dirty 加锁,在 dirty 中删除。
例如下图:
loading...
对 Collection 的操作#
通过对集合中的元素进行批量变换,从 A 到 B。这是 FP 的典型应用场景。
一些常用操作如下:
查询:Filter、Find、Max、Min
切分:Chunk、Take、Slice、Intersect、Distinct、Union
分组:GroupBy、AssociateBy、PartitionBy
断言:Any、None、All、Contains
变换:Map、FlatMap、Windowed、Zip
聚合:Reduce、Fold
Stream/Sequence 机制
一般情况下,可以通过链式调用将多个操作组合在一起,得到最终结果。
但是这样的操作会导致多个链式调用,进行多次重复迭代,比如下面的代码,所有结果都是立即计算的,进行了两遍集合的迭代。
val words = "The quick brown fox jumps over the lazy dog".split(" ")
val lengthsList = words
.filter { it.length > 3 }
.map { it.length }
.take(4)
通过引入类似 Sequence
的机制,使得多个链式调用进行组合,实现惰性计算、最终只迭代一次的目的。
比如 Java 中的 Stream API(Spliterator)。
val words = "The quick brown fox jumps over the lazy dog".split(" ")
val lengthsList = words
.asSequence()
.filter { it.length > 3 }
.map { it.length }
.take(4)
.toList()
其他参考#
Why are Java Streams once-off? - Stack Overflow
lambda - How To Use Classic Custom Data Structures As Java 8 Streams - Stack Overflow